我們將學習如何撰寫自定義的 gomock matcher
,以增強我們Golang單元測試的強度。
我們已經學習了如何使用bcrypt
安全地儲存用戶的密碼,並且也實現了為我們簡單的銀行應用創建新用戶的API。
按照前面所學,如果你嘗試自己為createUser
的API寫單元測試,你可能會發現它有點複雜,主要是因為輸入的密碼參數在儲存到數據庫之前會被Hashing
。
api/user_test.go
func randomUser(t *testing.T) (user db.User, password string) {
password = util.RandomString(6)
hashedPassword, err := util.HashPassword(password)
require.NoError(t, err)
user = db.User{
Username: util.RandomOwner(),
HashedPassword: hashedPassword,
FullName: util.RandomOwner(),
Email: util.RandomEmail(),
}
// naked return
return
}
func requireBodyMatchUser(t *testing.T, body *bytes.Buffer, user db.User) {
data, err := io.ReadAll(body)
require.NoError(t, err)
var gotUser db.User
err = json.Unmarshal(data, &gotUser)
require.NoError(t, err)
require.Equal(t, user.Username, gotUser.Username)
require.Equal(t, user.FullName, gotUser.FullName)
require.Equal(t, user.Email, gotUser.Email)
require.Empty(t, gotUser.HashedPassword)
}
func TestCreateUserAPI(t *testing.T) {
user, password := randomUser(t)
testCases := []struct {
name string
body gin.H
buildStubs func(store *mockdb.MockStore)
checkResponse func(recorder *httptest.ResponseRecorder)
}{
{
name: "OK",
body: gin.H{
"username": user.Username,
"password": password,
"full_name": user.FullName,
"email": user.Email,
},
buildStubs: func(store *mockdb.MockStore) {
// arg := db.CreateUserParams{
// Username: user.Username,
// HashedPassword: user.HashedPassword,
// FullName: user.FullName,
// Email: user.Email,
// }
store.EXPECT().
CreateUser(gomock.Any(), gomock.Any()).
Times(1).
Return(user, nil)
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
require.Equal(t, http.StatusOK, recorder.Code)
requireBodyMatchUser(t, recorder.Body, user)
},
},
{
name: "InternalError",
body: gin.H{
"username": user.Username,
"password": password,
"full_name": user.FullName,
"email": user.Email,
},
buildStubs: func(store *mockdb.MockStore) {
store.EXPECT().
CreateUser(gomock.Any(), gomock.Any()).
Times(1).
Return(db.User{}, sql.ErrConnDone)
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
require.Equal(t, http.StatusInternalServerError, recorder.Code)
},
},
{
name: "DuplicateUsername",
body: gin.H{
"username": user.Username,
"password": password,
"full_name": user.FullName,
"email": user.Email,
},
buildStubs: func(store *mockdb.MockStore) {
store.EXPECT().
CreateUser(gomock.Any(), gomock.Any()).
Times(1).
Return(db.User{}, &pq.Error{Code: pq.ErrorCode("23505")})
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
require.Equal(t, http.StatusForbidden, recorder.Code)
},
},
{
name: "InvalidUsername",
body: gin.H{
"username": "invalid-user#name",
"password": password,
"full_name": user.FullName,
"email": user.Email,
},
buildStubs: func(store *mockdb.MockStore) {
store.EXPECT().
CreateUser(gomock.Any(), gomock.Any()).
Times(0)
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
require.Equal(t, http.StatusBadRequest, recorder.Code)
},
},
{
name: "InvalidEmail",
body: gin.H{
"username": user.Username,
"password": password,
"full_name": user.FullName,
"email": "invalid-Email",
},
buildStubs: func(store *mockdb.MockStore) {
store.EXPECT().
CreateUser(gomock.Any(), gomock.Any()).
Times(0)
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
require.Equal(t, http.StatusBadRequest, recorder.Code)
},
},
{
name: "TooShortPassword",
body: gin.H{
"username": user.Username,
"password": "12345",
"full_name": user.FullName,
"email": user.Email,
},
buildStubs: func(store *mockdb.MockStore) {
store.EXPECT().
CreateUser(gomock.Any(), gomock.Any()).
Times(0)
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
require.Equal(t, http.StatusBadRequest, recorder.Code)
},
},
}
for i := range testCases {
tc := testCases[i]
t.Run(tc.name, func(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
store := mockdb.NewMockStore(ctrl)
tc.buildStubs(store)
server := NewServer(store)
recorder := httptest.NewRecorder()
data, err := json.Marshal(tc.body)
require.NoError(t, err)
url := "/users"
request, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(data))
require.NoError(t, err)
server.router.ServeHTTP(recorder, request)
tc.checkResponse(recorder)
})
}
}
這個測試涵蓋了以下幾種情況:
接來下我們將關注在”The successful case
",這個測試使用了gomock.Any()
作為匹配器,這其實會削弱測試的能力,尤其是在驗證CreateUser
函數的參數時。
func TestCreateUserAPI(t *testing.T) {
user, password := randomUser(t)
testCases := []struct {
name string
body gin.H
buildStubs func(store *mockdb.MockStore)
checkResponse func(recoder *httptest.ResponseRecorder)
}{
{
name: "OK",
body: gin.H{
"username": user.Username,
"password": password,
"full_name": user.FullName,
"email": user.Email,
},
buildStubs: func(store *mockdb.MockStore) {
store.EXPECT().
CreateUser(gomock.Any(), gomock.Any()).
Times(1).
Return(user, nil)
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
require.Equal(t, http.StatusOK, recorder.Code)
requireBodyMatchUser(t, recorder.Body, user)
},
},
...
}
...
}
CreateUserParams
api/user.go
func (server *Server) createUser(ctx *gin.Context) {
...
arg := db.CreateUserParams{}
user, err := server.store.CreateUser(ctx, arg)
if err != nil {
if pqErr, ok := err.(*pq.Error); ok {
switch pqErr.Code.Name() {
case "unique_violation":
ctx.JSON(http.StatusForbidden, errorResponse(err))
return
}
}
ctx.JSON(http.StatusInternalServerError, errorResponse(err))
return
}
...
}
這邊將CreateUserParams
設置為Empty
,我們預期在Unit Test會出現錯誤StatusInternalServerError
然後卻能通過測試,這是非常糟糕的,因為handler的實作是完全錯誤的,但是測試卻無法偵測到!
func (server *Server) createUser(ctx *gin.Context) {
...
hashedPassword, err := util.HashPassword("xyz")
if err != nil {
ctx.JSON(http.StatusInternalServerError, errorResponse(err))
return
}
arg := db.CreateUserParams{
Username: req.Username,
HashedPassword: hashedPassword,
FullName: req.FullName,
Email: req.Email,
}
user, err := server.store.CreateUser(ctx, arg)
if err != nil {
if pqErr, ok := err.(*pq.Error); ok {
switch pqErr.Code.Name() {
case "unique_violation":
ctx.JSON(http.StatusForbidden, errorResponse(err))
return
}
}
ctx.JSON(http.StatusInternalServerError, errorResponse(err))
return
}
...
}
這邊將hashedPassword
設置”xyz
” ,來模擬user input過短的密碼,我們預期在Unit Test會出現錯誤,但實際還是通過了測試:
util.HashPassword
**函數對生成的密碼進行哈希。require.NoError()
**來驗證。goCopy code
user, password := randomUser(t)
hashedPassword, err := util.HashPassword(password)
require.NoError(t, err)
gomock.Eq()
MatcherbuildStubs
函數中,建立一個新的arg
變量,其類型為db.CreateUserParams
。gomock.Eq(arg)
來替換gomock.Any()
,以便精確地匹配參數。api/user_test.go
func TestCreateUserAPI(t *testing.T) {
user, password := randomUser(t)
hashedPassword, err := util.HashPassword(password)
require.NoError(t, err)
testCases := []struct {
name string
body gin.H
buildStubs func(store *mockdb.MockStore)
checkResponse func(recoder *httptest.ResponseRecorder)
}{
{
name: "OK",
body: gin.H{
"username": user.Username,
"password": password,
"full_name": user.FullName,
"email": user.Email,
},
buildStubs: func(store *mockdb.MockStore) {
arg := db.CreateUserParams{
Username: user.Username,
HashedPassword: hashedPassword,
FullName: user.FullName,
Email: user.Email,
}
store.EXPECT().
CreateUser(gomock.Any(), gomock.Eq(arg)).
Times(1).
Return(user, nil)
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
require.Equal(t, http.StatusOK, recorder.Code)
requireBodyMatchUser(t, recorder.Body, user)
},
},
...
}
...
}
Empty
CreateUserParams
api/user.go
func (server *Server) createUser(ctx *gin.Context) {
...
arg := db.CreateUserParams{}
user, err := server.store.CreateUser(ctx, arg)
if err != nil {
if pqErr, ok := err.(*pq.Error); ok {
switch pqErr.Code.Name() {
case "unique_violation":
ctx.JSON(http.StatusForbidden, errorResponse(err))
return
}
}
ctx.JSON(http.StatusInternalServerError, errorResponse(err))
return
}
...
}
empty
,由於使用了更強大的Eq()
匹配器,測試應該會失敗。bcrypt
的Random Saltapi/user.go
func (server *Server) createUser(ctx *gin.Context) {
...
arg := db.CreateUserParams{
Username: req.Username,
HashedPassword: hashedPassword,
FullName: req.FullName,
Email: req.Email,
}
user, err := server.store.CreateUser(ctx, arg)
if err != nil {
if pqErr, ok := err.(*pq.Error); ok {
switch pqErr.Code.Name() {
case "unique_violation":
ctx.JSON(http.StatusForbidden, errorResponse(err))
return
}
}
ctx.JSON(http.StatusInternalServerError, errorResponse(err))
return
}
...
}
然而,即使createUser
處理程序正確地接收所有輸入參數,測試仍然會失敗。
原因是bcrypt
使用隨機鹽,所以即使密碼相同,每次哈希也會不同。
因此,我們不能僅使用內置的gomock.Eq()
匹配器來比較參數。
這樣,你就可以更準確地測試是否正確地處理了所有輸入參數和哈希密碼。
gomock
matchergomock
提供的 Matcher
接口有兩個方法:Matches()
和 String()
。
func Eq(x interface{}) Matcher { return eqMatcher{x} }
type Matcher interface {
// Matches returns whether x is a match.
Matches(x interface{}) bool
// String describes what the matcher matches.
String() string
}
Matches()
判斷輸入 x
是否匹配。String()
用於日誌,描述匹配器的功能。anyMatcher
type anyMatcher struct{}
func (anyMatcher) Matches(interface{}) bool {
return true
}
func (anyMatcher) String() string {
return "is anything"
}
eqMatcher
reflect.DeepEqual
to compare the actual input argument with the expected one.type eqMatcher struct {
x interface{}
}
func (e eqMatcher) Matches(x interface{}) bool {
return reflect.DeepEqual(e.x, x)
}
func (e eqMatcher) String() string {
return fmt.Sprintf("is equal to %v", e.x)
}
api/user_test.go
創建一個名為 eqCreateUserParamsMatcher
的結構。arg
(類型為 db.CreateUserParams
)和 password
。api/user_test.go
type eqCreateUserParamsMatcher struct {
arg db.CreateUserParams
password string
}
Matches()
方法中,將 x
轉換為 db.CreateUserParams
對象。
arg, ok := x.(db.CreateUserParams)
是一種稱為「型別斷言」(Type Assertion)的 Go 語言特性。x
轉換成 db.CreateUserParams
型別。ok
會被設為 true
,並且 arg
會存儲轉換後的值。ok
會被設為 false
,並且 arg
會是 db.CreateUserParams
型別的零值。Matches
函數的參數 x
是**interface{}
**類型,這意味著它可以是任何型別。透過型別斷言,我們可以安全地將它轉換為我們需要的特定型別(在這個例子中為 db.CreateUserParams
),然後進行後續的操作。util.CheckPassword()
函數來檢查哈希密碼是否與預期的密碼匹配。eqCreateUserParamsMatcher
結構體中,e.arg.HashedPassword = arg.HashedPassword
這行程式碼做的事情是將從 Matches
函數的參數 x
轉型得到的 db.CreateUserParams
型別的 arg
的 HashedPassword
欄位值設定給 e.arg.HashedPassword
。e.arg
(也就是 eqCreateUserParamsMatcher
結構體中的 arg
欄位)的 HashedPassword
值,使其與從函數參數得到的 arg
的 HashedPassword
值相同。reflect.DeepEqual(e.arg, arg)
這個比較能夠正確地執行。因為在這之前已經使用 util.CheckPassword(e.password, arg.HashedPassword)
驗證了密碼,所以現在需要確保兩個 db.CreateUserParams
物件在其他所有欄位(包括 HashedPassword
)上也是相同的。func (e eqCreateUserParamsMatcher) Matches(x interface{}) bool {
arg, ok := x.(db.CreateUserParams)
if !ok {
return false
}
err := util.CheckPassword(e.password, arg.HashedPassword)
if err != nil {
return false
}
e.arg.HashedPassword = arg.HashedPassword
return reflect.DeepEqual(e.arg, arg)
}
String()
方法以包含預期的參數和明文密碼值。func (e eqCreateUserParamsMatcher) String() string {
return fmt.Sprintf("matches arg %v and password %v", e.arg, e.password)
}
EqCreateUserParams
func EqCreateUserParams(arg db.CreateUserParams, password string) gomock.Matcher {
return eqCreateUserParamsMatcher{arg, password}
}
簡化 eqCreateUserParamsMatcher
結構體的創建過程,並且返回一個實現了 gomock.Matcher
介面的物件。這樣做有幾個好處:
eqCreateUserParamsMatcher
的內部結構,使得外部代碼不需要直接與它互動,只需要通過這個函式即可。arg
和 password
),然後函式會為你做剩下的事。EqCreateUserParams
)會使代碼更容易理解和維護。eqCreateUserParamsMatcher
的內部實現改變了,你只需要更新 EqCreateUserParams
函式內部的實現,而不需要改變使用這個 Matcher 的所有地方。創建一個名為 EqCreateUserParams()
的函數,它接受一個 db.CreateUserParams
對象和一個明文密碼字符串。
函數返回一個 Matcher
接口實例。
如果沒有 EqCreateUserParams
這個工廠函式,你仍然可以手動創建 eqCreateUserParamsMatcher
結構的實例。具體來說,你會需要做類似下面這樣的操作:
matcher := eqCreateUserParamsMatcher{
arg: db.CreateUserParams{
Username: "exampleUsername",
// 其他字段
},
password: "examplePassword",
}
然後在 gomock
的 EXPECT()
語句中使用這個 matcher
:
store.EXPECT().
CreateUser(gomock.Any(), matcher).
Times(1).
Return(user, nil)
這樣做雖然可行,但會使代碼變得更冗長和難以維護。使用工廠函式可以將這些細節封裝起來,使得代碼更簡潔、易讀和易於維護。
TestCreateUserAPI
中,使用 EqCreateUserParams()
替換 gomock.Eq()
。func TestCreateUserAPI(t *testing.T) {
user, password := randomUser(t)
testCases := []struct {
name string
body gin.H
buildStubs func(store *mockdb.MockStore)
checkResponse func(recoder *httptest.ResponseRecorder)
}{
{
name: "OK",
body: gin.H{
"username": user.Username,
"password": password,
"full_name": user.FullName,
"email": user.Email,
},
buildStubs: func(store *mockdb.MockStore) {
arg := db.CreateUserParams{
Username: user.Username,
FullName: user.FullName,
Email: user.Email,
}
store.EXPECT().
CreateUser(gomock.Any(), EqCreateUserParams(arg, password)).
Times(1).
Return(user, nil)
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
require.Equal(t, http.StatusOK, recorder.Code)
requireBodyMatchUser(t, recorder.Body, user)
},
},
...
}
...
}
Empty
CreateUserParams
測試通過,證明自定義匹配器的實現是正確的。
func (server *Server) createUser(ctx *gin.Context) {
...
arg := db.CreateUserParams{}
user, err := server.store.CreateUser(ctx, arg)
if err != nil {
if pqErr, ok := err.(*pq.Error); ok {
switch pqErr.Code.Name() {
case "unique_violation":
ctx.JSON(http.StatusForbidden, errorResponse(err))
return
}
}
ctx.JSON(http.StatusInternalServerError, errorResponse(err))
return
}
...
}
當輸入參數不匹配預期值時,測試會失敗,從而使單元測試更強大。
func (server *Server) createUser(ctx *gin.Context) {
...
hashedPassword, err := util.HashPassword("xyz")
if err != nil {
ctx.JSON(http.StatusInternalServerError, errorResponse(err))
return
}
...
}